arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/web/src/routes/[...path]/+page.svelte
jellyfin-year-review/web/src/routes/[...path]/+page.svelteRaw
  1<script>
  2  import { onMount } from 'svelte';
  3  import Poster from '../../lib/Poster.svelte';
  4  import Chart from '../../lib/Chart.svelte';
  5  import ViewingTimeline from '../../lib/ViewingTimeline.svelte';
  6  import RankingPosition from '../../lib/RankingPosition.svelte';
  7
  8  let user = $state(null), years = $state([]), year = $state(''), review = $state(null);
  9  let loading = $state(true), signingIn = $state(false), error = $state(''), notice = $state('');
 10  let username = $state(''), password = $state(''), slide = $state(0);
 11  let touch = null, request = 0;
 12  let authMode = $state('jellyfin');
 13  const chapters = [
 14    { id: 0, name: 'Your viewing recap' },
 15    { id: 1, name: 'Your watch time', availability: 'exactWatchTime' },
 16    { id: 2, name: 'Movies watched' },
 17    { id: 3, name: 'TV shows watched' },
 18    { id: 4, name: 'Unfinished movies', availability: 'completion' },
 19    { id: 5, name: 'Your top genres' },
 20    { id: 6, name: 'Viewing by hour' },
 21    { id: 7, name: 'Viewing by weekday' },
 22    { id: 8, name: 'Viewing by month' },
 23    { id: 9, name: 'Your devices and apps' },
 24    { id: 10, name: 'Your longest viewing session', availability: 'marathon' },
 25    { id: 11, name: 'Your viewing profile', availability: 'personality' },
 26    { id: 12, name: 'Your watch-time ranking', availability: 'ranking' },
 27    { id: 13, name: 'Your year in summary' }
 28  ];
 29  // Navigation and chapter numbering follow only the available cards for this year.
 30  let cards = $derived(chapters.filter(chapter => !chapter.availability || review?.availability?.[chapter.availability]?.available !== false));
 31  let card = $derived(cards[slide]?.id);
 32  const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
 33  const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
 34  const count = (n) => new Intl.NumberFormat().format(n);
 35  const hours = (n) => new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(n / 3600);
 36  const duration = (n) => `${hours(n)} h`;
 37  const interactive = (target) => target instanceof Element && !!target.closest('button, input, select, a, textarea');
 38
 39  async function api(path, options = {}) {
 40    const res = await fetch(path, { credentials: 'same-origin', ...options });
 41    const body = await res.json();
 42    if (!res.ok) {
 43      if (res.status === 401 && path !== '/api/login') { user = null; review = null; }
 44      throw new Error(body.error || 'The request could not be completed.');
 45    }
 46    return body;
 47  }
 48  async function loadReview() {
 49    if (!year) return;
 50    const current = ++request;
 51    loading = true; error = ''; review = null; slide = 0;
 52    try {
 53      const data = await api(`/api/review?year=${encodeURIComponent(year)}`);
 54      if (current !== request) return;
 55      if (data.schemaVersion !== 1) throw new Error('This review format is not supported. Ask the operator to update the app.');
 56      review = data;
 57      history.replaceState(null, '', `/review/${year}`);
 58    } catch (e) { if (current === request) error = e.message; }
 59    finally { if (current === request) loading = false; }
 60  }
 61  async function bootstrap() {
 62    loading = true; error = '';
 63    try {
 64      const config = await api('/api/config'); authMode = config.authMode;
 65      const session = await api('/api/session'); user = session.user; years = session.years;
 66      const pathYear = location.pathname.split('/').filter(Boolean)[1];
 67      year = String(years.includes(Number(pathYear)) ? Number(pathYear) : years.at(-1) || '');
 68      if (year) await loadReview();
 69    } catch (e) { if (user || !e.message.includes('expired')) error = e.message; }
 70    finally { loading = false; }
 71  }
 72  async function login(event) {
 73    event.preventDefault(); signingIn = true; error = ''; notice = '';
 74    try {
 75      await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
 76      password = ''; await bootstrap();
 77    } catch (e) { error = e.message; }
 78    finally { password = ''; signingIn = false; }
 79  }
 80  async function logout() {
 81    notice = ''; error = '';
 82    try { await api('/api/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); }
 83    catch (e) { notice = e.message; }
 84    finally { request++; user = null; review = null; years = []; password = ''; history.replaceState(null, '', '/'); }
 85  }
 86  function navigate(delta) { slide = Math.max(0, Math.min(cards.length - 1, slide + delta)); }
 87  function keydown(event) {
 88    if (!review || interactive(event.target) || event.altKey || event.ctrlKey || event.metaKey) return;
 89    if (event.key === 'ArrowRight') { event.preventDefault(); navigate(1); }
 90    if (event.key === 'ArrowLeft') { event.preventDefault(); navigate(-1); }
 91    if (event.key === 'Home') { event.preventDefault(); slide = 0; }
 92    if (event.key === 'End') { event.preventDefault(); slide = cards.length - 1; }
 93  }
 94  function pointerdown(event) { if (event.pointerType === 'touch' && !interactive(event.target)) touch = { x: event.clientX, y: event.clientY }; }
 95  function pointerup(event) {
 96    if (!touch) return;
 97    const dx = event.clientX - touch.x, dy = event.clientY - touch.y; touch = null;
 98    if (Math.abs(dx) > 65 && Math.abs(dx) > Math.abs(dy) * 1.5) navigate(dx < 0 ? 1 : -1);
 99  }
100  onMount(bootstrap);
101</script>
102
103<svelte:head><title>Jellyfin Year Review</title><meta name="description" content="Your private Jellyfin year in movies and television." /></svelte:head>
104<svelte:window onkeydown={keydown} onpointerup={pointerup} onpointercancel={() => touch = null} />
105
106<div class="app-shell" class:login-only={!user}>
107  {#if user}
108  <header class="topbar">
109    <a class="brand" href="/" aria-label="Jellyfin Year Review home"><span class="brand-icon">J</span><span>Jellyfin <span class="brand-light">Year Review</span><small>A YEAR WORTH REPLAYING</small></span></a>
110    {#if user}
111      <div class="account">
112        {#if years.length > 1}<label class="year-picker">Year<select aria-label="Review year" bind:value={year} onchange={loadReview}>{#each years as y}<option value={String(y)}>{y}</option>{/each}</select></label>{/if}
113        <button class="quiet" onclick={logout}>Sign out</button>
114      </div>
115    {/if}
116  </header>
117  {/if}
118
119  <main>
120    {#if user}
121      {#if notice}<p class="message" role="status">{notice}</p>{/if}
122      {#if error}<div class="message error" role="alert">{error}<button class="quiet" onclick={bootstrap}>Try again</button></div>{/if}
123    {/if}
124    {#if !user}
125      <section class="login-layout" aria-label="Sign in">
126        <form class="login-panel" onsubmit={login}>
127          <h1>Your year,<br />replayed.</h1><p>Sign in with your existing Jellyfin account.</p>
128          {#if notice}<p class="message" role="status">{notice}</p>{/if}
129          {#if error}<div class="message error" role="alert">{error}</div>{/if}
130          <label for="username">Jellyfin username</label><input id="username" name="username" autocomplete="username" required maxlength="256" bind:value={username} />
131          <label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" bind:value={password} />
132          <button class="primary" type="submit" disabled={loading || signingIn}>{loading ? 'Loading…' : signingIn ? 'Signing in…' : 'Roll the credits'} <span aria-hidden="true">↗</span></button>
133          <small class="login-note">{authMode === 'offline' ? 'Your credentials are checked locally against a private Jellyfin account snapshot. Password changes take effect after the operator refreshes it.' : 'Your credentials are validated by your Jellyfin server. Your review stays private.'}</small>
134        </form>
135      </section>
136    {:else if loading}
137      <section class="state-screen"><span class="loading-orbit" aria-hidden="true"></span><h1>Setting the scene…</h1><p role="status">Loading your private review</p></section>
138    {:else if !years.length}
139      <section class="state-screen"><p class="eyebrow">NOT QUITE SHOWTIME</p><h1>Your story is on its way.</h1><p>No review has been generated for your account. Ask your server operator to generate a year.</p><button class="primary" onclick={bootstrap}>Check again</button></section>
140    {:else if review}
141      <div class="review-meta"><span>{review.year} / {user.name}</span></div>
142      <nav class="progress" aria-label="Review chapters">{#each cards as chapter, i}<button class:active={slide === i} class:visited={slide > i} aria-label="{i + 1}. {chapter.name}" aria-current={slide === i ? 'step' : undefined} onclick={() => slide = i}><span></span></button>{/each}</nav>
143      <section class="story tone-{slide % 4}" aria-roledescription="carousel" aria-label="Your yearly review" onpointerdown={pointerdown}>
144        <div class="story-orbit" aria-hidden="true"></div>
145        <div class="story-body" aria-live="polite" aria-atomic="true">
146          <p class="eyebrow">CHAPTER {String(slide + 1).padStart(2, '0')} / {cards[slide].name.toUpperCase()}</p>
147          {#if card === 0}
148            <span class="year-stamp">{review.year}</span><h1>Your viewing recap</h1><p class="story-lede">{user.name}, {review.records ? 'see the movies, TV shows, and viewing habits that made up your year.' : 'no viewing activity was recorded for this year.'}</p><button class="primary" onclick={() => navigate(1)}>View my recap <span aria-hidden="true">→</span></button><span class="giant-play" aria-hidden="true">▶</span>
149          {:else if card === 1}
150            <h2>Your watch time</h2><div class="big-stat">{hours(review.seconds)}<span>hours watched{review.availability.exactWatchTime.available ? '' : ' · end-attributed estimate'}</span></div>
151            <div class="stat-grid"><div><b>{duration(review.movieSeconds)}</b><span>Movies</span></div><div><b>{duration(review.tvSeconds)}</b><span>Television</span></div><div><b>{count(review.records)}</b><span>Activity records</span></div><div><b>{count(review.uniqueTitles)}</b><span>Unique movies & episodes</span></div></div>
152          {:else if card === 2 || card === 3}
153            {@const items = card === 2 ? review.movies : review.shows}
154            <h2>{card === 2 ? 'Movies watched' : 'TV shows watched'}</h2>
155            <p>Everything you watched in {review.year}, grouped by month.</p>
156            <ViewingTimeline {items} year={review.year} kind={card === 2 ? 'Movies' : 'TV shows'} />
157          {:else if card === 4}
158            <h2>Unfinished movies</h2><p class="story-lede">Movies where you watched less than {review.rules.completionPercent}% of the runtime.</p>
159            {#if review.abandoned.length}<ol class="content-list compact">{#each review.abandoned.slice(0, 5) as item}<li><Poster {item} /><div><h3>{item.title}</h3><p>{Math.round(item.completionPercent)}% of the movie watched</p></div></li>{/each}</ol>{:else}<p class="empty-note">{review.availability.completion.available ? 'No unfinished movies found in supported records.' : 'Completion is unavailable without runtime and actual position ranges.'}</p>{/if}
160          {:else if card === 5}
161            <h2>Your top genres</h2><p>Genres ranked by watch time.</p>{#if review.genres.length}<Chart values={review.genres.slice(0, 7).map(v => v.seconds / 3600)} labels={review.genres.slice(0, 7).map(v => v.name)} metric="h" />{:else}<p class="empty-note">No genre history is available.</p>{/if}
162          {:else if card === 6}
163            <h2>Viewing by hour</h2><p>Your viewing activity across the hours of the day.</p><div class="hour-chart"><Chart values={review.hourly} labels={Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)} metric={review.distributionMetric === 'watched minutes' ? 'min' : 'records'} /></div>
164          {:else if card === 7}
165            <h2>Viewing by weekday</h2><p>Your viewing activity for each day of the week.</p><Chart values={review.weekdays} labels={weekdays} metric={review.distributionMetric === 'watched minutes' ? 'min' : 'records'} />
166          {:else if card === 8}
167            <h2>Viewing by month</h2><p>Your viewing activity for each month of {review.year}.</p><Chart values={review.monthly} labels={months} metric={review.distributionMetric === 'watched minutes' ? 'min' : 'records'} />
168          {:else if card === 9}
169            <h2>Your devices and apps</h2><p>Your most-used devices and apps, ranked by watch time.</p><div class="two-columns"><div><h3>Devices</h3><Chart values={review.devices.slice(0, 5).map(v => v.seconds / 3600)} labels={review.devices.slice(0, 5).map(v => v.name)} metric="h" /></div><div><h3>Apps & clients</h3><Chart values={review.clients.slice(0, 5).map(v => v.seconds / 3600)} labels={review.clients.slice(0, 5).map(v => v.name)} metric="h" /></div></div>
170          {:else if card === 10}
171            <h2>Your longest viewing session</h2>{#if review.marathon}<div class="big-stat">{hours(review.marathon.watchedSeconds)}<span>hours watched in your longest session</span></div><p class="story-lede">{duration(review.marathon.elapsedSeconds)} elapsed · {review.marathon.records} records</p><p>{new Date(review.marathon.start).toLocaleString(undefined, { timeZone: review.timezone })}</p>{:else}<p class="empty-note">{review.availability.marathon.reason}</p>{/if}
172          {:else if card === 11}
173            <h2>Your viewing profile</h2>{#if review.personality}<span class="personality-symbol" aria-hidden="true">✳</span><h3 class="personality-name">{review.personality.name}</h3><p class="story-lede">{review.personality.explanation}</p>{:else}<p class="empty-note">{review.availability.personality.reason}</p>{/if}
174          {:else if card === 12}
175            <h2>Your watch-time ranking</h2><p>Your total watch time compared with other eligible viewers.</p>{#if review.ranking}<div class="big-stat">#{review.ranking.rank}<span>of {review.ranking.cohort} eligible viewers</span></div><RankingPosition ranking={review.ranking} /><div class="stat-grid"><div><b>{Math.round(review.ranking.percentile)}%</b><span>Viewers with less watch time</span></div><div><b>{duration(review.seconds)}</b><span>Your total hours watched · movies &amp; TV shows</span></div></div>{:else}<p class="empty-note">{review.availability.ranking.reason}</p>{/if}
176          {:else}
177            <p class="year-stamp">SUMMARY / {review.year}</p><h2>Your year in summary</h2><div class="stat-grid">{#if review.availability.exactWatchTime.available !== false}<div><b>{duration(review.seconds)}</b><span>Your watch time</span></div>{/if}{#if review.availability.personality.available !== false}<div><b>{review.personality?.name || 'No viewing profile'}</b><span>Your viewing profile</span></div>{/if}</div><p class="story-lede">You have reached the end of your viewing recap.</p><button class="primary" onclick={() => slide = 0}>View recap again ↺</button>
178          {/if}
179        </div>
180        <div class="story-bottom"><span>{String(slide + 1).padStart(2, '0')} <span class="muted">/ {cards.length}</span></span><div class="story-controls"><button aria-label="Previous chapter" onclick={() => navigate(-1)} disabled={slide === 0}>←</button><button aria-label="Next chapter" onclick={() => navigate(1)} disabled={slide === cards.length - 1}>→</button></div></div>
181      </section>
182      <details class="source-notes"><summary>Behind your review · source & availability</summary><p>{review.source}</p><p>{review.coverage}</p><p>Snapshot: {new Date(review.generatedAt).toISOString()} · Format v{review.schemaVersion}</p>{#each review.warnings as warning}<p>{warning}</p>{/each}</details>
183    {:else}<section class="state-screen"><h1>Let’s try that again.</h1><button class="primary" onclick={bootstrap}>Reload review</button></section>{/if}
184  </main>
185  {#if user}<footer><span>MADE FOR THE LOVE OF THE WATCH.</span></footer>{/if}
186</div>